第 6 课:Memory(记忆系统)
(一)三种记忆类型
这是架构层面的区分,不是“概念分类”。
1. 短期记忆(Short-term Memory / Context)
我们在本节课之前接触的所有内容都是短期记忆
短期记忆就是指当前对话上下文,一般直接明文写入 prompt 的 message 部分,每轮对话用 append 方式更新。
一般包含:对话要求,当前任务的中间结果,最近的 Observation
由于受到 token 使用量的限制,最大容量 极小,而且无法方便地管理。因此也不能长期存储知识,不能当数据库,也不能当学习机制。
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": json.dumps({"state": state, "tools_available": sorted(tools_available)}, ensure_ascii=False)},
]
2. 工作记忆(Working Memory / State)
工作记忆一般主要存储当前步骤的目标,补充的注意事项,已经完成的内容等。一般用变量 state 表示。
state = {
"goal": "Schedule a meeting",
"constraints": {...},
"steps_done": ["enumerate_slots"],
"current_plan": "filter conflicts",
"budget": {"steps_left": 3}
}
工作记忆 State 由代码逻辑直接控制和维护,而不是由 LLM 生成。
它以 json/dict 的结构化格式保存在内存中,而不是 自然语言格式。因此可以用硬编码的逻辑直接管理检索。
重点区分程序记忆 和 模型记忆 的区别:
- 程序记忆:维持在本地内存中,以变量的形式存在,哪怕没有被作为 prompt 的一部分上传给模型,只要这个变量没有被消除,程序就可以调用它,这个记忆就始终“可用”
- 模型记忆:也是在本地内存中,但是区别在于只要这段内容没有被作为 prompt 上传到模型侧,模型就“不记得”
工作记忆 State 既可以供程序直接使用,也可以转化成 str 后插入 prompt,成为 短期记忆的一部分。(例如上面的例子)
也可以不输入到 短期记忆中,例如:
state = {
"goal": "Schedule meeting",
"steps_done": ["enumerate_slots"]
}
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": "Continue."}
]
此时:
- 程序 仍然知道 已经做过
enumerate_slots - 模型 不知道
- 程序可以:
- 强制下一步只能
filter_conflicts - 拒绝模型乱来
- 强制下一步只能
👉 State 还活着,短期记忆已经失忆
而反过来
messages = [
{"role": "user", "content": '{"steps_done": ["enumerate_slots"]}'}
]
state = {} # 程序没记录
此时:
- 模型“以为”自己已经枚举过
- 程序无法验证
- 这一步一旦错了,你无从发现
👉 这是“幻觉最容易发生的地方”
因此,工作记忆一般被用于:
- Planner / Executor 的状态流
- 防止模型“忘记自己在干什么”
- 限制行动空间(guardrails)
👉 State 是“程序的记忆”,不是“模型的记忆”
3. 长期记忆(Long-term Memory)
长期记忆则不再以 str 和 json 形式,保存在单个对话的临时变量内存中,而是保存在数据库中,可以跨对话、跨任务,可以检索(embedding + similarity)和复用。
通常是:
- 向量数据库(semantic memory)
- 或结构化规则库(symbolic memory)
4. 三种记忆的关系
长期记忆 ──检索──▶ 短期记忆(prompt)
▲ │
│ ▼
写入◀──总结/反思── 工作记忆(state)
(二)长期记忆的实现
你要做“长期记忆”,本质上是要让 Agent 具备能力:
把过去学过的东西(知识/规则/经验)存起来,并在未来遇到类似问题时快速找回来。
而“向量数据库检索”这条路的核心流程是:
- 把文本切成一段段(chunk)
- 把每段文本变成一个向量(embedding)
- 用户来一个新问题 → 把问题也变成向量
- 用向量相似度找最相关的几个 chunk(Top-K)
- 把这些 chunk 塞回 prompt(短期记忆)给模型用
这个流程就是所谓的 RAG
1. 什么是 chunk?
chunk = 你存进长期记忆的一条“最小可检索片段”。
你可以把长期记忆当成一本书的索引系统:
- 如果你把整本书当成一个条目:搜索很难命中正确位置(太粗)
- 如果你把每个字当成条目:条目太碎,检索噪声很大(太细)
所以要切成合适长度的“段落级单位”——这就是 chunk。
chunk 通常包含:
text: 文本内容(你要记住的那段话)type: 记忆类别(knowledge / rule / reflection / event…)tags: 标注(用于过滤、路由、多通道记忆)- 还可以加:
source、timestamp、confidence、id
例子:
chunk = {
"text": "When scheduling problems mention 'after X', interpret it as strictly later than X.",
"type": "rule",
"tags": ["time_constraint", "boundary_condition"]
}
意思是:把“时间边界解释规则”作为一条可检索规则记忆存进去,未来遇到“after 9:00”这种问题可以检索出来。
不同“记忆类型”天然粒度不同:
| 类型 | 为什么这样切 |
|---|---|
| 知识(200–500 tokens) | 需要上下文完整,段落级最合适 |
| 对话总结(100–200 tokens) | 总结本来就短,太长会浪费 token |
| 规则(1条=1chunk) | 规则要精确可复用,不要混在一起 |
| 反思(1失败=1chunk) | 每次失败的经验是独立样本,方便检索与去重 |
那么如何 用代码 按照语意 来 把长文切成 chunk 呢?
-
规则/反思:结构化写入,写一条规则就是一个 chunk。
-
知识文档:按 token / 段落切
-
字符近似:仅适合本地测试,字符数 ≈ token 数 × 3~4,每个 chunk 控制在一个大概范围
from typing import List def chunk_by_chars( text: str, max_chars: int = 1200, overlap: int = 150 ) -> List[str]: chunks = [] start = 0 # 切分的起始位置 text = text.strip() # 清除首尾/n while start < len(text): end = min(len(text), start + max_chars) chunk = text[start:end].strip() if chunk: chunks.append(chunk) # 下一个 chunk 起点(带 overlap) start = end - overlap if start < 0: start = 0 if end == len(text): break return chunks if __name__ == "__main__": doc = ( "Agents are systems that combine a language model with tools and memory. " "They can plan, act, observe results, and iterate. " "Long-term memory allows agents to store experiences and retrieve them later. " * 20 ) print(type(doc)) chunks = chunk_by_chars(doc, max_chars=500, overlap=80) for i, c in enumerate(chunks): print(f"\n--- Chunk {i} (len={len(c)}) ---\n{c[:120]}...")--- Chunk 0 (len=500) ---
Agents are systems that combine a language model with tools and memory. They can plan, act, observe results, and iterate...
--- Chunk 1 (len=500) ---
t combine a language model with tools and memory. They can plan, act, observe results, and iterate. Long-term memory all...
--- Chunk 2 (len=499) ---
odel with tools and memory. They can plan, act, observe results, and iterate. Long-term memory allows agents to store ex...
--- Chunk 3 (len=500) ---
mory. They can plan, act, observe results, and iterate. Long-term memory allows agents to store experiences and retrieve...这段代码每 500 个字符作为一个 chunk,每个chunk 之间有 80 个字符的 重叠部分
-
tokenizer:需要 tokenizer(或 API)
常见 tokenizer 选择
tokenizer 适用模型 tiktokenOpenAI / GPT-4 / GPT-4o HuggingFace tokenizer LLaMA / Qwen / DeepSeek 这里采用
tiktokenpip install tiktoken下面是这段代码的逻辑:
-
把原始文本转化成 token 列表
-
每 200 个 token 作为一个 chunk,每个chunk 之间有 40 个token 的 重叠部分
-
把每个 chunk 对应的 token 列表重新转化成 文本
import tiktoken from typing import List def chunk_by_tokens( text: str, max_tokens: int = 300, overlap: int = 50, model_name: str = "gpt-4o" ) -> List[str]: """ 使用 tiktoken 按 token 数精确切分 """ enc = tiktoken.encoding_for_model(model_name) print(enc) tokens = enc.encode(text) # 把文本改写成 token 列表,如 [112240, 553, 7511, 484 …… chunks = [] start = 0 while start < len(tokens): end = min(len(tokens), start + max_tokens) token_chunk = tokens[start:end] chunk_text = enc.decode(token_chunk).strip() # 把 token 列表重新转化成 文本 if chunk_text: chunks.append(chunk_text) start = end - overlap if start < 0: start = 0 if end == len(tokens): break return chunks if __name__ == "__main__": doc = ( "Agents are systems that combine a language model with tools and memory. " "They can plan, act, observe results, and iterate. " "Long-term memory allows agents to store experiences and retrieve them later. " * 20 ) chunks = chunk_by_tokens( doc, max_tokens=200, overlap=40, model_name="gpt-4o" ) for i, c in enumerate(chunks): print(f"\n--- Chunk {i} ---") print(c[:120], "...") -
-
而工程上你会把每个 chunk 包装成 MemoryChunk(type="knowledge") 写入向 量库。
2. 什么是 embedding?
embedding = 把一段文本变成一个高维向量(例如 768 维 / 1536 维),让计算机可以用数学方式衡量“语义相似”。
类比一下:
- 文本:人类理解
- 向量:机器理解
向量空间里:语义相似的文本 → 向量距离更近 / 内积更大
所以检索不是靠关键词,而是靠“语义相似”。
那么如何把 一段chunk文本变成一个向量呢?
-
用 embedding API:OpenAI / 其他服务提供 embedding 模型,质量高
import requests BASE_URL = "https://api.cometapi.com/v1" def embed_with_api(text: str, model: str = "text-embedding-3-large"): url = f"{BASE_URL}/embeddings" headers = { "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json" } payload = { "model": model, "input": text } resp = requests.post(url, headers=headers, json=payload, timeout=30) resp.raise_for_status() data = resp.json() # 这是一个 list[float] embedding = data["data"][0]["embedding"] return embedding if __name__ == "__main__": text = "When scheduling problems mention 'after X', interpret it as strictly later than X." vec = embed_with_api(text) print("Embedding dimension:", len(vec)) print("First 8 numbers:", vec[:8])Embedding dimension: 3072
First 8 numbers: [-0.0036590735, 0.011169595, -0.014485568, 0.033508785, 0.013652608, -0.0048391, 0.013295625, 0.046772677] -
用
sentence-transformers:本地跑,方便调试,质量取决于模型pip install sentence-transformers numpyfrom sentence_transformers import SentenceTransformer import numpy as np # 常用、轻量、效果不错 MODEL_NAME = "all-MiniLM-L6-v2" model = SentenceTransformer(MODEL_NAME) def embed_with_st(text: str): # 返回 numpy array vec = model.encode(text, normalize_embeddings=True) return vec if __name__ == "__main__": text = "When scheduling problems mention 'after X', interpret it as strictly later than X." vec = embed_with_st(text) print("Embedding shape:", vec.shape) print("First 8 numbers:", vec[:8])Embedding shape: (384,)
First 8 numbers: [-0.02017134 0.0222491 0.02075322 0.02680894 0.01276185 -0.00740438
0.00387796 -0.05653415]384 维(这个模型的固定维度)
思考:为什么两种方法得到的向量形状不同?
你可以理解为:
- 384 维:压缩后的“摘要特征”
- 3072 维:更接近“完整语义状态”
维度差异来自:模型架构 + 训练目标 + 工程定位
- 3072 维:大模型 API embedding(高保真、对齐 LLM)
- 384 维:轻量 sentence-transformers(高效、通用检索),
all-MiniLM-L6-v2的固定输出维度就是 384
注意:相似度计算必须要在 同一模型内部,embedding 向量只在“同一个向量空间”里比较才有意义
也就是说:
- 384 维 vs 384 维 → OK
- 3072 维 vs 3072 维 → OK
- ❌ 384 vs 3072 → 毫无意义
3. 向量数据库
向量数据库解决的问题非常具体:
给你一堆文 本(chunk),每段文本都有一个 embedding 向量。 你输入一个 query,也做 embedding。 向量数据库要做的是:快速找到与 query 向量最相似的 Top-K 向量,并返回它们对应的原文 chunk。
它本质是一个“语义检索引擎”,核心能力包括:
- 存储:保存向量(embedding)+ 元数据(text/type/tags/id/source…)
- 索引:构建近似最近邻索引(ANN),让检索从 O(N) 变成近似 O(log N) 或更快
- 检索(retrieve):Top-K nearest neighbors(按 cosine / inner product / L2)
- 过滤/路由(生产常用):按 type/tags/time 等过滤后再检索
- 持久化与扩展(Milvus 擅长):分布式、高可用、多租户
下面是一个例子,实现了:
- 对文本进行 token 化,然后切分为 N 个 chunk,再把 token 转回文本形态
- 对 文本形态的 chunk list 进行向量化,得到一个 (N × dim)形状的 numpy 矩阵(dim 为向量大小,由模型类型决定)
- 把 单个 chunk 按照
((明文+详细信息),向量)为单个条目,写入数据库
from dataclasses import dataclass, field
from typing import List, Dict, Any
import numpy as np
import tiktoken
from sentence_transformers import SentenceTransformer
# 按照 token 切分好 chunk,并重新转化为 str,输出结果是一个 List,内含多个 chunk 文本
def chunk_by_tokens(text: str, max_tokens: int = 60, overlap: int = 10, model_name: str = "gpt-4o") -> List[str]:
enc = tiktoken.encoding_for_model(model_name)
tokens = enc.encode(text)
chunks = []
start = 0
while start < len(tokens):
end = min(len(tokens), start + max_tokens)
chunk_text = enc.decode(tokens[start:end]).strip()
if chunk_text:
chunks.append(chunk_text)
start = end - overlap
if start < 0:
start = 0
if end == len(tokens):
break
return chunks
# 定义向量化工具
class Embedder:
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
self.model = SentenceTransformer(model_name)
self.dim = self.model.get_sentence_embedding_dimension() # 获取向量化后的向量维度
# 向量化工具,把文本转化为一个 numpy 高维向量
def encode(self, texts: List[str]) -> np.ndarray:
vecs = self.model.encode(texts, normalize_embeddings=True)
return np.asarray(vecs, dtype=np.float32)
# 定义写入数据库的一个条目的格式,每一个条目都要新建一个实例
@dataclass
class MemoryChunk:
id: int
text: str
type: str = "knowledge"
tags: List[str] = field(default_factory=list)
meta: Dict[str, Any] = field(default_factory=dict) # 元数据 = 描述数据的来源
# 定义数据库结构
class MemoryStore:
"""
最小长期记忆存储:
- chunks: 记忆条目(文本+元数据)
- vectors: 对应 embedding(与 chunks 按 index 对齐)
"""
def __init__(self, dim: int):
self.dim = dim
self.chunks: List[MemoryChunk] = []
self.vectors: List[np.ndarray] = []
def add(self, chunk: MemoryChunk, vector: np.ndarray):
assert vector.shape == (self.dim,)
self.chunks.append(chunk)
self.vectors.append(vector)
if __name__ == "__main__":
doc = """
Agent systems combine an LLM with tools and memory to perform tasks.
Long-term memory stores chunks of validated knowledge, rules, and reflections.
Chunking splits long text into retrievable pieces. Embeddings convert text to vectors.
Vector search finds semantically similar chunks via cosine similarity.
Tool calling lets the model request structured function invocations.
Guardrails restrict action space and validate outputs to reduce hallucinations.
""".strip()
# 1) chunk
chunk_texts = chunk_by_tokens(doc, max_tokens=25, overlap=5) # 是 一个list,形如 [str,str,……]
# 2) embed
embedder = Embedder()
vecs = embedder.encode(chunk_texts) # 是一个 numpy 向量,维度为 (chunk 数 N, 单个 array 维度 dim)
# 注:同一个模型,生成的 向量的维度是固定的,因为输出层的节点数是固定的
# 3) store
store = MemoryStore(dim=embedder.dim)
for i, (t, v) in enumerate(zip(chunk_texts, vecs)):
chunk = MemoryChunk(
id=i, # 第几个 chunk
text=t, # 该 chunk 的对应文本
type="knowledge", # 该 chunk 的 类型,分为:knowledge,rule,reflection,event,persona/user_profile
tags=["agent", "memory"], # 该 chunk 的标签
meta={ # 元数据 = 描述数据的来源
"source": "demo_doc_v1",
"created_at": "2025-12-27",
"token_max": 25,
"overlap": 5,
}
)
store.add(chunk, v) # 把一个 chunk 和 对应的 向量存入
print("Stored items:", len(store.chunks))
print("Example item:", store.chunks[0])
print("Example vector dim:", store.vectors[0].shape)
Stored items: 4
Example item: MemoryChunk(id=0, text='Agent systems combine an LLM with tools and memory to perform tasks.\nLong-term memory stores chunks of validated knowledge, rules,', type='knowledge', tags=['agent', 'memory'], meta={'source': 'demo_doc_v1', 'created_at': '2025-12-27', 'token_max': 25, 'overlap': 5})
Example vector dim: (384,)
该代码运行完成后,实例 store 内部的 chunks 和 vectors 属性各自维护一个 list,内部元素意义对应,分别是一个 chunk 的 MemoryChunk 实例 和 向量化结果。
思考:为什么要
class MemoryChunk?直接存字符串不行吗?MemoryChunk 中保存更多的背景信息,包括创建条件,来源,类型,版本,时间等,和对应的 embedding 对齐的 主键 ID